テーブルデータ処理に悩むあなたに朗報!Polarsの使い方を徹底解説 その2:重複・欠損処理編、ほか
こんちには。
データアナリティクス事業本部 機械学習チームの中村です。
本記事では、世間でも話題となっているPolarsについて基本的な使い方を抑えていく記事のその2です。
私自身「データサイエンス100本ノック」をPolarsで一通り実施しましたので、それを元に実践に必要な使い方とノウハウをご紹介します。
本記事でPolarsの使い方とノウハウを習得し、実践的なテクニックを身につけて頂ければと思います。
なお、前回記事は以下となります。
本記事の内容
本記事では前回触れられなかった以下を見ていきます。
- 遅延評価
- ユニーク処理・重複の対応
- 欠損値の対応
- 数学関数
- データ処理(ダミー変数化、pivot、サンプリングなど)
APIリファレンスは以下を参照ください。
使用環境
Google Colab環境でやっていきます。特にスペックは限定しません。
セットアップ
pipでインストールするのみで使用できるようになります。
!pip install polars
執筆時点ではpolars-0.16.5
がインストールされました。
インポートはplという略称が使われているようです。
import polars as pl
ここからサンプルとしてデータサイエンス100本ノックのcsvデータを使用します。
※本記事は、「データサイエンティスト協会スキル定義委員」の「データサイエンス100本ノック(構造化データ加工編)」を利用しています※
以降は、以下のファイルが配置されている前提として進めます。動かしてみたい方はダウンロードされてください。
./category.csv ./customer.csv ./geocode.csv ./product.csv ./receipt.csv ./store.csv
以降のコードを動かす際は、以下のようにread_csv
したと仮定して進めます。
dtypes = { 'customer_id': str, 'gender_cd': str, 'postal_cd': str, 'application_store_cd': str, 'status_cd': str, 'category_major_cd': str, 'category_medium_cd': str, 'category_small_cd': str, 'product_cd': str, 'store_cd': str, 'prefecture_cd': str, 'tel_no': str, 'postal_cd': str, 'street': str, 'application_date': str, 'birth_day': str } df_category = pl.read_csv("category.csv", dtypes=dtypes) df_customer = pl.read_csv("customer.csv", dtypes=dtypes) df_geocode = pl.read_csv("geocode.csv", dtypes=dtypes) df_product = pl.read_csv("product.csv", dtypes=dtypes) df_receipt = pl.read_csv("receipt.csv", dtypes=dtypes) df_store = pl.read_csv("store.csv", dtypes=dtypes)
pl.Series
関連の操作
pl.Series
の抽出: pl.DataFrame.get_column
前回記事ではあまりpl.Series
は登場していませんでしたが、以下でpl.DataFrame
から取り出すことが可能です。
df_receipt.get_column("amount")
また上記でカラム名をリストで与えると、取得されるものがpl.DataFrame
となりますので、そこは注意が必要です。
すべての列をそれぞれpl.Series
で抽出: pl.DataFrame.get_columns
pl.DataFrame.get_columns
でそれぞれの列がpl.Series
となったリストを取得できます。
series_list = df_receipt.get_columns()
ここからカラムに対応するindex番号をpl.DataFrame.find_idx_by_name
で取得して、pl.Series
を取得できます。
df_receipt.get_columns()[ df_receipt.find_idx_by_name("sales_ymd") ]
ユニーク処理・重複の対応
ユニークな行の抽出: pl.DataFrame.unique
pl.DataFrame.unique
を使ってsubset
で指定したカラムについてユニークなレコードを抽出可能です。
df_receipt.unique(subset=["customer_id"])
重複は削除された状態で表示されます。
削除されないレコードの判定基準は、デフォルトではkeep='first'
となっており、最初にあるレコードが残される形となります。keep
には'first', 'last', 'none'
のいずれかを設定可能です。
なお、subset
には複数カラムを指定することが可能です。
df_receipt.unique(subset=["customer_id", "product_cd"])
pandasでは、drop_duplicates
というメソッドでしたので、ここは混乱しないように注意が必要です。
ユニーク数: pl.Expr.n_unique
ユニーク数は、pl.Expr.n_unique()
またはpl.Expr.unique().count()
で取得可能です。
df_receipt.select([ pl.col("receipt_no").n_unique() ])
ユニークな値を取り出す: pl.Series.unique
一旦、pl.Series
を取り出してからunique
を実行することで、ユニークな値を得ることができます。
df_receipt.get_column("store_cd").unique()
ここからは逆に重複の判定を見ていきます。
レコード全体が完全に重複しているレコードを抽出: pl.DataFrame.is_duplicated
pl.DataFrame.is_duplicated
は、全カラムが完全に重複しているかどうかの真偽値を取得します。
よって以下のようにpl.DataFrame.filter
と組み合わせると、完全に一致したレコードのみを抽出することができます。
df_receipt.filter( df_receipt.is_duplicated() )
ある単一カラムが重複しているレコードの抽出: pl.Expr.is_duplicated
逆に特定のカラムが重複しているかどうかは、pl.DataFrame.filter
内でpl.Expr.is_duplicated
を使用する必要があります。
以下は誕生日が重複している"customer_name"を抽出する処理です。
df_customer.select([ "customer_name", "birth_day" ]).filter( (pl.col("birth_day").is_duplicated()) ).sort("birth_day")
複数カラムが重複しているレコードの抽出
複数カラムが重複する場合は、少し工夫が必要です。
pl.DataFrame.select
とfilter
および全体の重複を見るpl.DataFrame.is_duplicated
を組み合わせる必要があります
以下は誕生日と郵便番号が重複している"customer_name"を抽出する処理です。
df_customer.select([ "customer_name", "birth_day", "postal_cd" ]).filter( df_customer.select(["birth_day", "postal_cd"]).is_duplicated() ).sort("birth_day")
以下のように単一カラムの場合と同様に記述すると、それぞれのカラムが独立して重複判定されてしまいます。
# NGな例 df_customer.select([ "customer_name", "birth_day", "postal_cd" ]).filter( (pl.col("birth_day").is_duplicated()) & (pl.col("postal_cd").is_duplicated()) ).sort("birth_day").head(10)
重複の対応にまつわる補足
この辺りの記述の複雑さは、pl.DataFrame.is_duplicated
がsubset
引数を使えるようになれば、改善の余地がありそうです。
現段階ではpl.DataFrame.select
とfilter
およびpl.DataFrame.is_duplicated
を組み合わせた記述が最も汎用性が高い記述方法かなと感じました。
ユニーク判定: pl.Expr.is_unique
とpl.DataFrame.is_unique
pl.Expr.is_unique
とpl.DataFrame.is_unique
はそれぞれ、pl.Expr.is_duplicated
とpl.DataFrame.is_duplicated
の真偽が逆転したものなので割愛します。
欠損値対応
Intでもnull
を扱い可能
Polarsでは欠損値をInt型でも扱うことが可能です。
df = pl.DataFrame({ "a": [1,2,3,4] , "b": [1,2,3,None] }) df.dtypes
[Int64, Int64]
pandasでは、Noneを入れた時点でデータ型がfloat扱いになってしまいます。
# pandasの場合 df = pd.DataFrame({ "a": [1,2,3,4] , "b": [1,2,3,None] }) df.dtypes
a int64 b float64 dtype: object
nullとNaNの違い
NumPyのnp.nanを要素に入れた場合はNaN
扱い、None
の場合はnull
として区別して扱われます。(pandasではどちらもNaN
扱いです)
またNaN
はInt型では表現できないため、Float型になってしまう点は注意が必要です。
import numpy as np df = pl.DataFrame({ "a": [1,2,3,4] , "b": [1,2,3,None] , "c": [1,2,3,np.nan] }) print(df.dtypes) df
[Int64, Int64, Float64]
Polarsでは区別して扱うため、以降の判定等のメソッドでも区別されます。
欠損値のカウント: pl.DataFrame.null_count
各カラムの欠損値をpl.DataFrame.null_count
で数えることができます。(なお、nan_count
は存在しないため注意が必要)
df_product.null_count()
pandasの場合は、isnull().sum()
という形で求める必要がありました。
# pandasで同等処理をする例 df_product.isnull().sum()
欠損かどうか: pl.Expr.is_null
, pl.Expr.is_nan
is_duplicated
やis_unique
などと違い、is_null
はpl.DataFrame
には存在せず、pl.Expr
のみに存在します。
df_product.filter( (pl.col("unit_cost").is_null()) | (pl.col("unit_price").is_null()) )
なお、is_nan
についてもほぼ同様の使い方となっていますので、割愛します。
DataFrame全体への欠損値埋め: pl.DataFrame.fill_null
, pl.DataFrame.fill_nan
欠損値埋めはDataFrame全体への処理が可能です。pl.DataFrame.fill_null
が適用可能です。
df_product.fill_null(strategy='mean')
fill_null
はvalue
引数に固定値を指定するか、strategy
引数に'forward', 'backward', 'min', 'max', 'mean', 'zero', 'one'
いずれかを指定することで埋めることができます。
同様の処理がpl.DataFrame.fill_nan
としてありますが、こちらはstorategy
が指定できないため注意が必要です。
しかしfill_nan
は固定値埋め以外に、以下のように別の列の値で埋めることができます
df = pl.DataFrame( {"A": [1, 2, 3], "B": [np.nan, np.nan, np.nan]} ) df.fill_nan(pl.col("A"))
逆に別の列の値で埋めることは、fill_null
ではできないようです。
df = pl.DataFrame( {"A": [1, 2, 3], "B": [None, None, None]} ) df.fill_null(pl.col("A"))
カラムに対する欠損値埋め: pl.Expr.fill_null
, pl.Expr.fill_nan
カラムに対してはpl.Expr.fill_null
を使って埋めることができます。
カラムに対する場合は、以下のように別の列の値で埋めることも可能です。
df = pl.DataFrame( {"A": [1, 2, 3], "B": [None, None, None]} ) df.select([ pl.col("A") , pl.col("B").fill_null(pl.col("A")) ])
pl.Expr.fill_nan
もほぼ同様ですが、strategy
引数はないため注意が必要です。
動的な欠損値埋め: pl.coalesce
pl.coalesce
であればnullだった場合には埋めるなどというSQLクエリと同様な処理が可能です。
以下のようにpl.coalesce
を複数列指定してつなげることにより、より複雑な欠損値処理を行うことができます。
df = pl.DataFrame( {"A": [1, 2, 3], "B": [4, 5, None], "C": [None, None, None]} ) df.select( pl.coalesce([ pl.col("C"), pl.col("B"), pl.col("A") ]) )
欠損値の削除: pl.DataFrame.drop_nulls
pl.DataFrame.drop_nulls
で欠損値がカラムに一つでもあるレコードは削除できます。
df_product.drop_nulls()
転置: pl.DataFrame.transpose
pl.DataFrame.transpose
によって、カラム方向とレコード方向を入れ替えることができます。
df_product.null_count().transpose(include_header=True)
転置後にカラム名も含めたい場合は、引数をinclude_header=True
とする必要があります。
遅延評価
遅延評価と実行: pl.DataFrame.lazy
, pl.DataFrame.collect
lazy
を使うことで、collect
されるまで評価を遅らせることができます。途中結果を変数に格納する場合でも有効です。
以下は少し複雑な例題ですが、冒頭にlazy
を使い、最後にcollect
で結果を取得します。
P-039: レシート明細データフレーム(df_receipt)から売上日数の多い顧客の上位20件と、売上金額合計の多い顧客の上位20件を抽出し、完全外部結合せよ。ただし、非会員(顧客IDが"Z"から始まるもの)は除外すること。
df_data = df_receipt.lazy().filter( pl.col('customer_id').str.starts_with('Z').is_not() ) df_cnt = df_data.groupby('customer_id').agg( pl.col('sales_ymd').n_unique() ).sort('sales_ymd', reverse=True).head(20) df_sum = df_data.groupby('customer_id').agg( pl.col('amount').sum() ).sort('amount', reverse=True).head(20) df_cnt.join(df_sum, how='outer', on='customer_id').collect()
入力に複数のDataFrameを使う場合は、以下の例題のように双方ともにlazy
を使用する必要があります。
P-053: 顧客データフレーム(df_customer)の郵便番号(postal_cd)に対し、東京(先頭3桁が100〜209のもの)を1、それ以外のものを0に2値化せよ。さらにレシート明細データフレーム(df_receipt)と結合し、全期間において売上実績がある顧客数を、作成した2値ごとにカウントせよ。
df_customer.lazy().select([ "customer_id" , pl.when( pl.col("postal_cd").str.slice(0,3) .cast(pl.Int32).is_between(100, 209) ).then(1).otherwise(0).alias("is_tokyo") ]).join( df_receipt.lazy().select([ pl.col("customer_id") ]), how="inner", on="customer_id" ).groupby("postal_cd").agg( pl.col("customer_id").unique().count() ).collect()
またデバッグ用に制限した行数で評価したい場合は、collect
の代わりにfetch
を使用することができます。
ファイル読込も遅延評価: pl.scan_csv
ファイル読み込みはpl.read_csv
でしたが、ファイル読込を含めて遅延評価するには代わりにpl.scan_csv
を使用します。
df_receipt_lazy = pl.scan_csv("receipt.csv", dtypes=dtypes)
実行の際には、同様にcollect
すればOKです。
df_receipt_lazy.head(10).collect()
数学関数
対数: pl.Expr.log10
polarsは数学関数が充実しているため、NumPyなしでも様々な処理が行えます。
P-061: レシート明細データフレーム(df_receipt)の売上金額(amount)を顧客ID(customer_id)ごとに合計し、売上金額合計を常用対数化(底=10)して顧客ID、売上金額合計とともに表示せよ。ただし、顧客IDが"Z"から始まるのものは非会員を表すため、除外して計算すること。結果は10件表示させれば良い
df_receipt.filter( pl.col("customer_id").str.starts_with("Z").is_not() ).groupby("customer_id").agg([ pl.col("amount").sum() ]).select([ "customer_id" , pl.col("amount").log10().alias("amount_log10") ]).sort("customer_id").head(10)
主な数学関数まとめ
数学関数で主に使用できるものを下表にまとめます。
処理内容 | polars | pandas |
---|---|---|
常用対数 | pl.Expr.log10 |
なし(numpyで処理) |
自然対数 | pl.Expr.log |
なし(numpyで処理) |
切り捨て | pl.Expr.floor |
なし(numpyで処理) |
丸め処理 | pl.Expr.round |
なし(numpyで処理) |
切り上げ | pl.Expr.ceil |
なし(numpyで処理) |
絶対値 | pl.Expr.abs |
Series.abs |
その他のデータ処理
出現回数のカウント: pl.Series.value_counts
, pl.Expr.value_counts
pl.Series.value_counts
を使えば、カテゴリなどの出現回数がpl.DataFrame
で得られます。
df_customer.get_column('gender_cd').value_counts()
pl.Expr.value_counts
は構造体が返されるため注意が必要です。
df_customer.select([ pl.col('gender_cd').value_counts() ])
ダミー変数化: pl.get_dummies
pl.get_dummies
で実施可能です。
まずselect
でダミー変数化したいカラムを抽出した後にget_dummies
で処理します。
それらをアンパックすることで横方向に結合します。
df_customer.select([ "customer_id" , *pl.get_dummies(df_customer.select(pl.col("gender_cd"))) ]).head(10)
ダミー変数の結果は以下のようにu8
(符号なし8bit整数)となります。
pandasも同名のget_dummies
がありますが、使い方は違うので注意が必要です。
# pandasでの同様の処理例 pd.get_dummies(df_customer[['customer_id', 'gender_cd']], columns=['gender_cd']).head(10)
クロス集計: pl.pivot
こちらはほぼpandasと同様で、values
, index
, columns
を引数に指定すればOKです。
以下に例題と回答を示します。
P-043: レシート明細データフレーム(df_receipt)と顧客データフレーム(df_customer)を結合し、性別(gender)と年代(ageから計算)ごとに売上金額(amount)を合計した売上サマリデータフレーム(df_sales_summary)を作成せよ。性別は0が男性、1が女性、9が不明を表すものとする。
ただし、項目構成は年代、女性の売上金額、男性の売上金額、性別不明の売上金額の4項目とすること(縦に年代、横に性別のクロス集計)。また、年代は10歳ごとの階級とすること。
df_customer.select( ["customer_id", "gender_cd", "age"] ).join( df_receipt.select( ["customer_id", "amount"] ), how="left", on="customer_id" ).select([ "gender_cd" , ((pl.col('age') / 10).floor() * 10).cast(pl.Int16).alias("era") , "amount" ]).groupby(["gender_cd", "era"]).agg( pl.col("amount").sum() ).pivot( values="amount", index="era", columns="gender_cd" ).sort("era").select([ "era" , pl.col("0").alias("male") , pl.col("1").alias("female") , pl.col("9").alias("unknown") ])
差分処理: pl.Expr.shift
pl.shift
で引数指定をなしとすれば、一つ前のレコードが参照でき、演算が可能になります。
P-041: レシート明細データフレーム(df_receipt)の売上金額(amount)を日付(sales_ymd)ごとに集計し、前日からの売上金額増減を計算せよ。なお、計算結果は10件表示すればよい。
df_receipt.groupby("sales_ymd").agg( pl.col("amount").sum() ).sort("sales_ymd")\ .with_columns( (pl.col("amount") - pl.col("amount").shift()).alias("diff_amount") ).head(10)
pandasでは複数のDataFrameを作成してconcatする必要があり煩雑でした。
# pandasの解答例 df_sales_amount_by_date = df_receipt[['sales_ymd', 'amount']].\ groupby('sales_ymd').sum().reset_index() df_sales_amount_by_date = pd.concat([df_sales_amount_by_date, df_sales_amount_by_date.shift()], axis=1) df_sales_amount_by_date.columns = ['sales_ymd','amount','lag_ymd','lag_amount'] df_sales_amount_by_date['diff_amount'] = \ df_sales_amount_by_date['amount'] - df_sales_amount_by_date['lag_amount'] df_sales_amount_by_date.head(10)
shift
に引数を指定すれば、指定した分前のレコードを参照することも可能です。
サンプリング: pl.DataFrame.sample
サンプリングは、pandasと同様sample
で実行できます。
df_customer.sample(frac=0.01).head(10)
この例ではfrac
のみ指定していますが、これは第2引数であり第1引数にサンプル数を指定できるn
が存在します。
n
を使う場合でもfrac
を使う場合でも、一度より多くレコードをサンプルする必要があるオーバーサンプリングの場合、with_replacement=True
を指定する必要があります。
まとめ
いかがでしたでしょうか。ほぼほぼ基本的な部分はこの「その2」まででおさえていますが、 まだ全てを書ききれていないので次回以降では以下のような内容を書いていこうと思います。
- 日付型の扱い
- UDF(ユーザ定義関数)とapply
- 他ライブラリとの連携(scikit-learnなど)
- 処理時間の計測・検証
本記事がPolarsを使われる方の参考になれば幸いです。